{
 "cells": [
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "# Amazon SageMaker Batch Transform\n",
    "_**Generating Machine Learning Model Predictions from a Batch Transformer versus from a Real Time Endpoint**_\n",
    "\n",
    "---\n",
    "\n",
    "---\n",
    "\n",
    "\n",
    "## Contents\n",
    "\n",
    "1. [Background](#Background)\n",
    "1. [Setup](#Setup)\n",
    "1. [Data](#Data)\n",
    "1. [Dimensionality reduction](#Dimensionality-reduction)\n",
    "  1. [Train PCA](#Train-PCA)\n",
    "  1. [Batch prediction PCA](#Batch-prediction-PCA)\n",
    "  1. [Real-time prediction comparison](#Real-time-prediction-comparison)\n",
    "  1. [Batch prediction on new data](#Batch-prediction-on-new-data)\n",
    "1. [Clustering](#Clustering)\n",
    "  1. [Prepare BYO](#Prepare-BYO)\n",
    "  1. [Train DBSCAN](#Train-DBSCAN)\n",
    "  1. [Batch prediction DBSCAN](#Batch-prediction-DBSCAN)\n",
    "1. [Evaluate](#Evaluate)\n",
    "1. [Wrap-up](#Wrap-up)\n",
    "\n",
    "---\n",
    "\n",
    "## Background\n",
    "\n",
    "This notebook provides an introduction to the Amazon SageMaker batch transform functionality.  Deploying a trained model to a hosted endpoint has been available in SageMaker since launch and is a great way to provide real-time predictions to a service like a website or mobile app.  But, if the goal is to generate predictions from a trained model on a large dataset where minimizing latency isn't a concern, then the batch transform functionality may be easier, more scalable, and more appropriate.  This can be especially useful for cases like:\n",
    "\n",
    "- **One-off evaluations of model fit:** For example, we may want to compare accuracy of our trained model on new validation data that we collected after our initial training job. \n",
    "- **Using outputs from one model as the inputs to another:** For example, we may want use a pre-processing step like word embeddings, principal components, clustering, or TF-IDF, before training a second model to generate predictions from that information.\n",
    "- **When predictions will ultimately be served outside of Amazon SageMaker:** For example, we may have a large, but finite, set of predictions to generate which we then store in a fast-lookup datastore for serving.\n",
    "\n",
    "Functionally, batch transform uses the same mechanics as real-time hosting to generate predictions.  It requires a web server that takes in HTTP POST requests a single observation, or mini-batch, at a time.  However, unlike real-time hosted endpoints which have persistent hardware (instances stay running until you shut them down), batch transform clusters are torn down when the job completes.\n",
    "\n",
    "The example we'll walk through in this notebook starts with Amazon movie review [data](https://s3.amazonaws.com/amazon-reviews-pds/readme.html), performs on principal components on the large user-item review matrix, and then uses DBSCAN to cluster movies in the reduced dimensional space.  This allows us to split the notebook into two parts as well as showcasing how to use batch with SageMaker built-in algorithms, and the bring your own algorithm use case.\n",
    "\n",
    "If you are only interested in understanding how SageMaker batch transform compares to hosting a real-time endpoint, you can stop running the notebook before the clustering portion of the notebook.\n",
    "\n",
    "---\n",
    "\n",
    "## Setup\n",
    "\n",
    "_This notebook was created and tested on an ml.m4.xlarge notebook instance._\n",
    "\n",
    "Let's start by specifying:\n",
    "\n",
    "- The S3 bucket and prefix that you want to use for training and model data.  This should be within the same region as the Notebook Instance, training, and hosting.  We've specified the default SageMaker bucket, but you can change this.\n",
    "- The IAM role arn used to give training and hosting access to your data. See the AWS SageMaker documentation for information on how to setup an IAM role.  Note, if more than one role is required for notebook instances, training, and/or hosting, please replace `sagemaker.get_execution_role()` with the appropriate full IAM role arn string(s)."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {
    "isConfigCell": true
   },
   "outputs": [],
   "source": [
    "import sagemaker\n",
    "sess = sagemaker.Session()\n",
    "\n",
    "bucket = sess.default_bucket()\n",
    "prefix = 'sagemaker/DEMO-batch-transform'\n",
    "\n",
    "role = sagemaker.get_execution_role()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now we'll import the Python libraries we'll need."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "import boto3\n",
    "import sagemaker\n",
    "import sagemaker.amazon.common as smac\n",
    "from sagemaker.amazon.amazon_estimator import get_image_uri\n",
    "from sagemaker.transformer import Transformer\n",
    "from sagemaker.predictor import csv_serializer, json_deserializer\n",
    "import matplotlib.pyplot as plt\n",
    "import pandas as pd\n",
    "import numpy as np\n",
    "import scipy.sparse\n",
    "import os\n",
    "import json"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Permissions\n",
    "\n",
    "Running the clustering portion of this notebook requires permissions in addition to the normal `SageMakerFullAccess` permissions. This is because we'll be creating a new repository in Amazon ECR. The easiest way to add these permissions is simply to add the managed policy `AmazonEC2ContainerRegistryFullAccess` to the role that you used to start your notebook instance. There's no need to restart your notebook instance when you do this, the new permissions will be available immediately.\n",
    "\n",
    "---\n",
    "\n",
    "## Data\n",
    "\n",
    "Let's start by bringing in our dataset from an S3 public bucket.  The Amazon review dataset contains 1 to 5 star ratings from over 2M Amazon customers on over 160K digital videos.  More details on this dataset can be found at its [AWS Public Datasets page](https://s3.amazonaws.com/amazon-reviews-pds/readme.html).\n",
    "\n",
    "_Note, because this dataset is over a half gigabyte, the load from S3 may take ~10 minutes.  Also, since Amazon SageMaker Notebooks start with a 5GB persistent volume by default, and we don't need to keep this data on our instance for long, we'll bring it to the temporary volume (which has up to 20GB of storage)._"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!mkdir /tmp/reviews/\n",
    "!aws s3 cp s3://amazon-reviews-pds/tsv/amazon_reviews_us_Digital_Video_Download_v1_00.tsv.gz /tmp/reviews/"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Let's read the data into a [Pandas DataFrame](https://pandas.pydata.org/pandas-docs/stable/generated/pandas.DataFrame.html) so that we can begin to understand it.\n",
    "\n",
    "*Note, we'll set `error_bad_lines=False` when reading the file in as there appear to be a very small number of records which would create a problem otherwise.*"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df = pd.read_csv('/tmp/reviews/amazon_reviews_us_Digital_Video_Download_v1_00.tsv.gz', delimiter='\\t',error_bad_lines=False)\n",
    "df.head()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We can see this dataset includes information like:\n",
    "\n",
    "- `marketplace`: 2-letter country code (in this case all \"US\").\n",
    "- `customer_id`: Random identifier that can be used to aggregate reviews written by a single author.\n",
    "- `review_id`: A unique ID for the review.\n",
    "- `product_id`: The Amazon Standard Identification Number (ASIN).  `http://www.amazon.com/dp/<ASIN>` links to the product's detail page.\n",
    "- `product_parent`: The parent of that ASIN.  Multiple ASINs (color or format variations of the same product) can roll up into a single parent parent.\n",
    "- `product_title`: Title description of the product.\n",
    "- `product_category`: Broad product category that can be used to group reviews (in this case digital videos).\n",
    "- `star_rating`: The review's rating (1 to 5 stars).\n",
    "- `helpful_votes`: Number of helpful votes for the review.\n",
    "- `total_votes`: Number of total votes the review received.\n",
    "- `vine`: Was the review written as part of the [Vine](https://www.amazon.com/gp/vine/help) program?\n",
    "- `verified_purchase`: Was the review from a verified purchase?\n",
    "- `review_headline`: The title of the review itself.\n",
    "- `review_body`: The text of the review.\n",
    "- `review_date`: The date the review was written.\n",
    "\n",
    "To keep the problem tractable and get started on batch transform quickly, we'll make a few simplifying transformations on the data.  Let's start by reducing our dataset to users, items, and start ratings.  We'll keep product title on the dataset for evaluating our clustering at the end."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "df = df[['customer_id', 'product_id', 'star_rating', 'product_title']]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now, because most users don't rate most products, and there's a long tail of products that are almost never rated, we'll tabulate common percentiles."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "customers = df['customer_id'].value_counts()\n",
    "products = df['product_id'].value_counts()\n",
    "\n",
    "quantiles = [0, 0.1, 0.25, 0.5, 0.75, 0.8, 0.85, 0.9, 0.95, 0.96, 0.97, 0.98, 0.99, 0.995, 0.999, 0.9999, 1]\n",
    "print('customers\\n', customers.quantile(quantiles))\n",
    "print('products\\n', products.quantile(quantiles))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "As we can see, only 0.1% of users have rated more than 36 movies.  And, only 25% of movies have been rated more than 8 times.  For the purposes of our analysis, we'd like to keep a large sample of popular movies for our clustering, but base that only on heavy reviewers.  So, we'll limit to customers who have reviewed 35+ movies and movies that have been reviewed 20+ times."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "customers = customers[customers >= 35]\n",
    "products = products[products >= 20]\n",
    "\n",
    "reduced_df = df.merge(pd.DataFrame({'customer_id': customers.index})).merge(pd.DataFrame({'product_id': products.index}))"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "customers = reduced_df['customer_id'].value_counts()\n",
    "products = reduced_df['product_id'].value_counts()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now, we'll setup to split our dataset into train and test.  Dimensionality reduction and clustering don't always require a holdout set to test accuracy, but it will allow us to illustrate how batch prediction might be used when new data arrives.  In this case, our test dataset will be a simple 10% sample of items."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "test_products = products.sample(frac=0.1)\n",
    "train_products = products[~(products.index.isin(test_products.index))]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now, to build our matrix, we'll give each of our customers and products their own unique, sequential index.  This will allow us to easily hold the data as a sparse matrix, and then write that out to S3 as a dense matrix, which will serve as the input to our PCA algorithm."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "customer_index = pd.DataFrame({'customer_id': customers.index, 'user': np.arange(customers.shape[0])})\n",
    "train_product_index = pd.DataFrame({'product_id': train_products.index, \n",
    "                                    'item': np.arange(train_products.shape[0])})\n",
    "test_product_index = pd.DataFrame({'product_id': test_products.index, \n",
    "                                   'item': np.arange(test_products.shape[0])})\n",
    "\n",
    "train_df = reduced_df.merge(customer_index).merge(train_product_index)\n",
    "test_df = reduced_df.merge(customer_index).merge(test_product_index)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Next, we'll create sparse matrices for the train and test datasets from the indices we just created and an indicator for whether the customer gave the rating 4 or more stars.  Note that this inherently implies a star rating below for all movies that a customer has not yet reviewed.  Although this isn't strictly true (it's possible the customer would review it highly but just hasn't seen it yet), our purpose is not to predict ratings, just to understand how movies may cluster together, so we use this simplification."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "train_sparse = scipy.sparse.csr_matrix((np.where(train_df['star_rating'].values >= 4, 1, 0), \n",
    "                                        (train_df['item'].values, train_df['user'].values)),\n",
    "                                       shape=(train_df['item'].nunique(), customers.count()))\n",
    "\n",
    "test_sparse = scipy.sparse.csr_matrix((np.where(test_df['star_rating'].values >= 4, 1, 0), \n",
    "                                       (test_df['item'].values, test_df['user'].values)),\n",
    "                                      shape=(test_df['item'].nunique(), customers.count()))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now, we'll save these files to dense CSVs.  This will create a dense matrix of movies by customers, with reviews as the entries, similar to:\n",
    "\n",
    "|Item     |User1|User2|User3|...|UserN|\n",
    "|---------|-----|-----|-----|---|-----|\n",
    "|**Item1**|1    |0    |0    |...|0    |\n",
    "|**Item2**|0    |0    |1    |...|1    |\n",
    "|**Item3**|1    |0    |0    |...|0    |\n",
    "|**...**  |...  |...  |...  |...|...  |\n",
    "|**ItemM**|0    |1    |1    |...|1    |\n",
    "\n",
    "Which translates to User1 positively reviewing Items 1 and 3, User2 positively reviewing ItemM, and so on."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "np.savetxt('/tmp/reviews/train.csv',\n",
    "           train_sparse.todense(),\n",
    "           delimiter=',',\n",
    "           fmt='%i')\n",
    "\n",
    "np.savetxt('/tmp/reviews/test.csv',\n",
    "           test_sparse.todense(),\n",
    "           delimiter=',',\n",
    "           fmt='%i')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "And upload them to S3.  Note, we'll keep them in separate prefixes to ensure the test dataset isn't picked up for training."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "train_s3 = sess.upload_data('/tmp/reviews/train.csv', \n",
    "                            bucket=bucket, \n",
    "                            key_prefix='{}/pca/train'.format(prefix))\n",
    "\n",
    "test_s3 = sess.upload_data('/tmp/reviews/test.csv',\n",
    "                           bucket=bucket,\n",
    "                           key_prefix='{}/pca/test'.format(prefix))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Finally, we'll create an input which can be passed to our SageMaker training estimator."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "train_inputs = sagemaker.s3_input(train_s3, content_type='text/csv;label_size=0')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "## Dimensionality reduction\n",
    "\n",
    "Now that we have our item user positive review matrix, we want to perform Principal Components Analysis (PCA) on it.  This can serve as an effective pre-processing technique prior to clustering.  Even though we filtered out customers with very few reviews, we still have 2348 users.  If we wanted to cluster directly on this data, we would be in a very high dimensional space.  This runs the risk of the curse of dimensionality.  Essentially, because we have such a high dimensional feature space, every point looks far away from all of the others on at least some of those dimensions.  So, We'll use PCA to generate a much smaller number of uncorrelated components.  This should make finding clusters easier.\n",
    "\n",
    "### Train PCA\n",
    "\n",
    "Let's start by creating a PCA estimator.  We'll define:\n",
    "- Algorithm container path\n",
    "- IAM role for data permissions and \n",
    "- Harware setup (instance count and type)\n",
    "- Output path (where our PCA model artifact will be saved)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "container = get_image_uri(boto3.Session().region_name, 'pca', 'latest')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pca = sagemaker.estimator.Estimator(container,\n",
    "                                    role,\n",
    "                                    train_instance_count=1,\n",
    "                                    train_instance_type='ml.m4.xlarge',\n",
    "                                    output_path='s3://{}/{}/pca/output'.format(bucket, prefix),\n",
    "                                    sagemaker_session=sess)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Then we can define hyperparameters like:\n",
    "- `feature_dim`: The number of features (in this case users) in our input dataset.\n",
    "- `num_components`: The number of features we want in our output dataset (which we'll pass to our clustering algorithm as input).\n",
    "- `subtract_mean`: Debiases our features before running PCA.\n",
    "- `algorithm_mode`: Since our dataset is rather large, we'll use randomized, which scales better.\n",
    "\n",
    "See the [documentation](https://docs.aws.amazon.com/sagemaker/latest/dg/PCA-reference.html) for more detail."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pca.set_hyperparameters(feature_dim=customers.count(),\n",
    "                        num_components=100,\n",
    "                        subtract_mean=True,\n",
    "                        algorithm_mode='randomized',\n",
    "                        mini_batch_size=500)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "And finally, we'll use `.fit()` to start the training job."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pca.fit({'train': train_inputs})"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Batch prediction PCA\n",
    "\n",
    "Now that our PCA training job has finished, let's generate some predictions from it.  We'll start by creating a batch transformer.  For this, we need to specify:\n",
    "- Hardware specification (instance count and type).  Prediction is embarassingly parallel, so feel free to test this with multiple instances, but since our dataset is not enormous, we'll stick to one.\n",
    "- `strategy`: Which determines how records should be batched into each prediction request within the batch transform job.  'MultiRecord' may be faster, but some use cases may require 'SingleRecord'.\n",
    "- `assemble_with`: Which controls how predictions are output.  'None' does not perform any special processing, 'Line' places each prediction on it's own line.\n",
    "- `output_path`: The S3 location for batch transform to be output.  Note, file(s) will be named with '.out' suffixed to the input file(s) names.  In our case this will be 'train.csv.out'.  Note that in this case, multiple batch transform runs will overwrite existing values unless this is updated appropriately."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pca_transformer = pca.transformer(instance_count=1,\n",
    "                                  instance_type='ml.m4.xlarge',\n",
    "                                  strategy='MultiRecord',\n",
    "                                  assemble_with='Line',\n",
    "                                  output_path='s3://{}/{}/pca/transform/train'.format(bucket, prefix))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now, we'll pass our training data in to get predictions from batch transformer.  A critical parameter to set properly here is `split_type`.  Since we are using CSV, we'll specify 'Line', which ensures we only pass one line at a time to our algorithm for prediction.  Had we not specified this, we'd attempt to pass all lines in our file, which would exhaust our transformer instance's memory.\n",
    "\n",
    "_Note: Here we pass the S3 path as input rather than input we use in `.fit()`._"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pca_transformer.transform(train_s3, content_type='text/csv', split_type='Line')\n",
    "pca_transformer.wait()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now that our batch transform job has completed, let's take a look at the output.  Since we've reduced the dimensionality so much, the output is reasonably small and we can just download it locally."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!aws s3 cp --recursive $pca_transformer.output_path ./"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!head train.csv.out"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We can see the records are output as JSON, which is typical for Amazon SageMaker built-in algorithms.  It's the same format we'd see if we performed real-time prediction.  However, here, we didn't have to stand up a persistent endpoint, and we didn't have to write code to loop through our training dataset and invoke the endpoint one mini-batch at a time.  Just for the sake of comparison, we'll show what that would look like here.\n",
    "\n",
    "### Real-time prediction comparison (optional)\n",
    "\n",
    "Now we'll deploy PCA to a real-time endpoint.  As mentioned above, if our use-case required individual predictions in near real-time, SageMaker endpoints make sense.  They can also be used for pseudo-batch prediction, but the process is more involved than simply using SageMaker batch transform.\n",
    "\n",
    "We'll start by deploying our PCA estimator."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pca_predictor = pca.deploy(initial_instance_count=1,\n",
    "                           instance_type='ml.m4.xlarge')"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now we need to specify our content type and how we serialize our request data (which needs to be help in local memory) to that type."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pca_predictor.content_type = 'text/csv'\n",
    "pca_predictor.serializer = csv_serializer\n",
    "pca_predictor.deserializer = json_deserializer"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Then, we setup a loop to:\n",
    "1. Cycle through our training dataset a 5MB or less mini-batch at a time.\n",
    "2. Invoke our endpoint.\n",
    "3. Collect our results.\n",
    "\n",
    "Importantly, If we wanted to do this:\n",
    "1. On a very large dataset, then we'd need to work out a means of reading just some of the dataset into memory at a time.\n",
    "2. In parallel, then we'd need to monitor and recombine the separate threads properly."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "components = []\n",
    "for array in np.array_split(np.array(train_sparse.todense()), 500):\n",
    "    result = pca_predictor.predict(array)\n",
    "    components += [r['projection'] for r in result['projections']]\n",
    "components = np.array(components)"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "components[:5, ]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "In order to use these values in a subsequent model, we would also have to output `components` to a local file and then save that file to S3.  And, of course we wouldn't want to forget to delete our endpoint."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "sess.delete_endpoint(pca_predictor.endpoint)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Batch prediction on new data\n",
    "\n",
    "Sometimes you may acquire more data after initially training your model.  SageMaker batch transform can be used in cases like these as well.  We can start by creating a model and getting it's name."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pca_model = sess.create_model_from_job(pca._current_job_name, name='{}-test'.format(pca._current_job_name))"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Now, we can create a transformer starting from the SageMaker model.  Our arguments are the same as when we created the transformer from the estimator except for the additional model name argument."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "pca_test_transformer = Transformer(pca_model,\n",
    "                                   1,\n",
    "                                   'ml.m4.xlarge',\n",
    "                                   output_path='s3://{}/{}/pca/transform/test'.format(bucket, prefix),\n",
    "                                   sagemaker_session=sess,\n",
    "                                   strategy='MultiRecord',\n",
    "                                   assemble_with='Line')\n",
    "pca_test_transformer.transform(test_s3, content_type='text/csv', split_type='Line')\n",
    "pca_test_transformer.wait()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Let's pull this in as well and take a peak to confirm it's what we expected.  Note, since we used 'MultiRecord', the first line in our file is enormous, so we'll only print out the first 10,000 bytes."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!aws s3 cp --recursive $pca_test_transformer.output_path ./"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!head -c 10000 test.csv.out"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We can see that we have output the reduced dimensional components for our test dataset, using the model we built from our training dataset.\n",
    "\n",
    "At this point in time, we've shown all of the batch functionality you need to get started using it in Amazon SageMaker.  The second half of the notebook takes our first set of batch outputs from SageMaker's PCA algorithm and passes them to a bring your own container version of the DBSCAN clustering algorithm.  Feel free to continue on for a deep dive.\n",
    "\n",
    "---\n",
    "\n",
    "---\n",
    "\n",
    "\n",
    "## Clustering (Optional)\n",
    "\n",
    "For the second half of this notebook we'll show you how you can use batch transform with a container that you've created yourself.  This uses [R](https://www.r-project.org/) to run the DBSCAN clustering algorithm on the reduced dimensional space which was output from SageMaker PCA.\n",
    "\n",
    "We'll start by walking through the three scripts we'll need for bringing our DBSCAN container to SageMaker.\n",
    "\n",
    "### Prepare BYO\n",
    "\n",
    "#### Dockerfile\n",
    "\n",
    "`Dockerfile` defines what libraries should be in our container.  We start with an Ubuntu base, and install R, dbscan, and plumber libraries.  Then we add `dbscan.R` and `plumber.R` files from our local filesystem to our container.  Finally, we set it to run `dbscan.R` as the entrypoint when launched.\n",
    "\n",
    "_Note: Smaller containers are preferred for Amazon SageMaker as they lead to faster spin up times in training and endpoint creation, so we keep the Dockerfile minimal._"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!cat Dockerfile"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### dbscan.R\n",
    "\n",
    "`dbscan.R` is the script that runs when the container starts.  It looks for either 'train' or 'serve' arguments to determine if we are training our algorithm or serving predictions, and it contains two functions `train()` and `serve()`, which are executed when appropriate.  It also includes some setup at the top to create shortcut paths so our algorithm can use the container directories as they are setup by SageMaker ([documentation](https://docs.aws.amazon.com/sagemaker/latest/dg/your-algorithms-training-algo.html)).\n",
    "\n",
    "The `train()` function reads in training data, which is actually the output from the SageMaker PCA batch transform job.  Appropriate transformations to read the file's JSON into a data frame are made.  And then takes in hyperparameters for DBSCAN.  In this case, that consists of `eps` (size of the neighborhood fo assess density) and `minPts` (minimum number of points needed in the `eps` region).  The DBSCAN model is fit, and model artifacts are output.\n",
    "\n",
    "The `serve()` function sets up a [plumber](https://www.rplumber.io/) API.  In this case, most of the work of generating predictions is done in the `plumber.R` script."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!cat dbscan.R"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "#### plumber.R\n",
    "\n",
    "This script functions to generate predictions for both real-time prediction from a SageMaker hosted endpoint and batch transform.  So, we return an empty message body on `/ping` and we load our model and generate predictions for requests sent to `/invocations`.  We're inherently expecting scoring input to come in the same SageMaker PCA output JSON format as we did in training.  This assumption may not be valid if we were making real-time requests rather than batch requests.  But, we could include additional logic to accommodate multiple input formats as needed."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!cat plumber.R"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Publish\n",
    "\n",
    "In the next step we'll build our container and publish it to ECR where SageMaker can access it.\n",
    "\n",
    "This command will take several minutes to run the first time."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "%%sh\n",
    "\n",
    "# The name of our algorithm\n",
    "algorithm_name=dbscan\n",
    "\n",
    "#set -e # stop if anything fails\n",
    "\n",
    "account=$(aws sts get-caller-identity --query Account --output text)\n",
    "\n",
    "# Get the region defined in the current configuration (default to us-west-2 if none defined)\n",
    "region=$(aws configure get region)\n",
    "region=${region:-us-west-2}\n",
    "\n",
    "fullname=\"${account}.dkr.ecr.${region}.amazonaws.com/${algorithm_name}:latest\"\n",
    "\n",
    "# If the repository doesn't exist in ECR, create it.\n",
    "\n",
    "aws ecr describe-repositories --repository-names \"${algorithm_name}\" > /dev/null 2>&1\n",
    "\n",
    "if [ $? -ne 0 ]\n",
    "then\n",
    "    aws ecr create-repository --repository-name \"${algorithm_name}\" > /dev/null\n",
    "fi\n",
    "\n",
    "# Get the login command from ECR and execute it directly\n",
    "$(aws ecr get-login --region ${region} --no-include-email)\n",
    "\n",
    "# Build the docker image locally with the image name and then push it to ECR\n",
    "# with the full name.\n",
    "docker build  -t ${algorithm_name} .\n",
    "docker tag ${algorithm_name} ${fullname}\n",
    "\n",
    "docker push ${fullname}"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Train DBSCAN\n",
    "\n",
    "Now that our container is built, we can create an estimator and use it to train our DBSCAN clustering algorithm.  note, we're passing in `pca_transformer.output_path` as our input training data."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "region = boto3.Session().region_name\n",
    "account = boto3.client('sts').get_caller_identity().get('Account')"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dbscan = sagemaker.estimator.Estimator('{}.dkr.ecr.{}.amazonaws.com/dbscan:latest'.format(account, region),\n",
    "                                       role,\n",
    "                                       train_instance_count=1,\n",
    "                                       train_instance_type='ml.m4.xlarge',\n",
    "                                       output_path='s3://{}/{}/dbscan/output'.format(bucket, prefix),\n",
    "                                       sagemaker_session=sess)\n",
    "dbscan.set_hyperparameters(minPts=5)\n",
    "dbscan.fit({'train': pca_transformer.output_path})"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "### Batch prediction\n",
    "\n",
    "Next, we'll kick off batch prediction for DBSCAN.  In this case, we'll choose to do this on our test output from above.  This again illustrates that although batch transform can be used to generate predictions on the training data, it can just as easily be used on holdout or future data as well.\n",
    "\n",
    "_Note: Here we use strategy 'SingleRecord' because each line from our previous batch output is from a 'MultiRecord' output, so we'll process all of those at once._"
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dbscan_transformer = dbscan.transformer(instance_count=1,\n",
    "                                        instance_type='ml.m4.xlarge',\n",
    "                                        output_path='s3://{}/{}/dbscan/transform'.format(bucket, prefix),\n",
    "                                        strategy='SingleRecord',\n",
    "                                        assemble_with='Line')\n",
    "dbscan_transformer.transform(pca_test_transformer.output_path, \n",
    "                             content_type='text/csv', \n",
    "                             split_type='Line')\n",
    "dbscan_transformer.wait()"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "---\n",
    "\n",
    "## Evaluate"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We'll start by bringing in the cluster output dataset locally."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "!aws s3 cp --recursive $dbscan_transformer.output_path ./"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Next we'll read the JSON output in to pick up the cluster membership for each observation."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dbscan_output = []\n",
    "with open('test.csv.out.out', 'r') as f:\n",
    "    for line in f:\n",
    "        result = json.loads(line)[0].split(',')\n",
    "        dbscan_output += [r for r in result]"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "We'll merge that information back onto our test data frame."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dbscan_clusters = pd.DataFrame({'item': np.arange(test_products.shape[0]),\n",
    "                                'cluster': dbscan_output})\n",
    "\n",
    "dbscan_clusters_items = test_df.groupby('item')['product_title'].first().reset_index().merge(dbscan_clusters)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "And now we'll take a look at 5 example movies from each cluster."
   ]
  },
  {
   "cell_type": "code",
   "execution_count": null,
   "metadata": {},
   "outputs": [],
   "source": [
    "dbscan_clusters_items.sort_values(['cluster', 'item']).groupby('cluster').head(2)"
   ]
  },
  {
   "cell_type": "markdown",
   "metadata": {},
   "source": [
    "Our clustering could likely use some tuning as we see some skewed cluster distributions.  But, we do find a few commonalities like \"Charlotte's Web\" and \"Wild Kratts Season 3\" both showing up in cluster #32, which may be kid's videos.\n",
    "\n",
    "_Note: Due to inherent randomness of the algorithms and data manipulations, your specific results may differ from those mentioned above._\n",
    "\n",
    "---\n",
    "\n",
    "## Wrap-up\n",
    "\n",
    "In this notebook we showcased how to use Amazon SageMaker batch transform with built-in algorithms and with a bring your own algorithm container.  This allowed us to set it up so that our custom container ingested the batch output of the first algorithm.  Extensions could include:\n",
    "- Moving to larger datasets, where batch transform can be particularly effective.\n",
    "- Using batch transform with the SageMaker pre-built deep learning framework containers.\n",
    "- Adding more steps or further automating the machine learning pipeline we've started."
   ]
  }
 ],
 "metadata": {
  "kernelspec": {
   "display_name": "conda_python3",
   "language": "python",
   "name": "conda_python3"
  },
  "language_info": {
   "codemirror_mode": {
    "name": "ipython",
    "version": 3
   },
   "file_extension": ".py",
   "mimetype": "text/x-python",
   "name": "python",
   "nbconvert_exporter": "python",
   "pygments_lexer": "ipython3",
   "version": "3.6.4"
  },
  "notice": "Copyright 2018 Amazon.com, Inc. or its affiliates. All Rights Reserved.  Licensed under the Apache License, Version 2.0 (the \"License\"). You may not use this file except in compliance with the License. A copy of the License is located at http://aws.amazon.com/apache2.0/ or in the \"license\" file accompanying this file. This file is distributed on an \"AS IS\" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License."
 },
 "nbformat": 4,
 "nbformat_minor": 2
}
